iT邦幫忙

2024 iThome 鐵人賽

DAY 16
0
Mobile Development

Flutter 開發實戰 - 30 天逃離新手村系列 第 16

Day 16 路由與 Navigator 1.0 vs 2.0

  • 分享至 

  • xImage
  •  

或許這是您一直期望說明的環節 - 關於如何切換頁面。行動裝置應用程式通常會被組織成多個頁面。你使用過的許多應用程式應該都使用這樣的設計。例如一個應用首先呈現的是一個電影列表,當你選擇其中一個項目的時候切換到另一個頁面顯示更多資訊。這就是由一個頁面導向另一個頁面。

在 Flutter ,在不同頁面之間切換叫做路由。由 Navigator 組件管理。Navigator 組件管理導航堆疊(Navigation Stack) 概念上類似圖層的堆疊進而決定那個畫面在前,那個在背景。Navigator 推送一個新路由到堆疊中或移除之前的路由。在這個章節,我們將學習如何使用 Navigator 管理應用程式的路由,以及如何加入轉場動畫,如何在不同頁面之間傳遞資料。將會含蓋下列主題:

  • 理解 Navigator
  • 理解路由
  • 轉場效果
  • 傳遞資料

理解 Navigator 組件

因為螢幕的關係,幾乎所有行動應用程式都超過一個頁面。如果你是 Android 或 iOS 開發者,你大概已經知道如何使用 ActivityViewController 負責控制呈現畫面。

而在 Flutter 中要切換頁面最重要的就是 Navigator 組件。它的任務就是管理這些畫面的切換也就是負責維護頁面的歷史記錄,讓使用者可以回到上一頁等操作

一個頁面在 Flutter 中就是一個新組件,它會被疊在目前的組件之上。這就是路由管理的概念,路由定義了可以造訪的頁面。沒錯這個類別就是 Route ,我們就是用它來協助達成整個瀏覽換頁。

簡單說主要的類別包含:

  • Navigator: 負責管理路由 Route,是 Flutter 中的路由管理器負責管理路由堆疊,控制頁面的導航和切換。
  • OverlayNavigator 用來指定該顯示的路由。Overlay 是一個特殊組件,位於整個應用程式的最頂層。可以把它想像成一個透明的畫布,覆蓋在整個應用程式上層。當你導航到一個新的畫面時,Navigator 會在 Overlay 上建立一個新的 OverlayEntry,並將新的畫面的組件加入。
  • Route :導航的端點,可以想成一個頁面。

我們將學習這些類別,但首先,我們需要了解隨著 Flutter 的發展,切換頁面的具體作法是如何演進。

Navigator 1.0 和 2.0

隨著 Flutter 擴展到網頁應用程式領域,演化成一個更加完整的開發框架。在頁面之間切換導航的方式也發生了改變。現在共有 2 種不同的導航方式。Navigator 1.0 採用命令式的風格,通過程式指示框架從堆疊中新增或移除頁面。這種方式適用於大多數情境。尤其對於 iOS 和 Andriod 開發者來說相對簡單易懂。

然而加入網頁的支援也帶來了新的挑戰,例如通過一個 URL 可以直接切換到應用程式流程深處的某個頁面。在 iOS 或 Android,通常會預期使用者從第一個頁面進入,然後從那個位置開始切換頁面,導航到其他頁面。但是在網頁上,你直接分享一個連結就可以直接到特定頁面。例如我們正在瀏覽一個書店網站,然後發現並分享某本書的連結,通常我們希望使用者可以直接造訪該頁面,同時又能夠使用原本流程會產生的堆疊效果,在這個例子就是希望上一頁可以回到列表頁。

這並不是網頁特有的情境,iOS 或 Android 的 Deep Link 也屬於這種情境。只是在網頁中這種情況比較頻繁且明顯,使用者可以進入應用程式的任何頁面,但使用 Navigator 1.0 不太適合處理這種情境。

Navigator 2.0 採用一種宣告式的風格,類似組件結構的撰寫方式。支援的頁面預先宣告定義,而狀態決定該呈現那個頁面。後續我們將會探討這兩種方式,因為它們都是可使用的方式。

許多生態圈社群的成員認為這樣的命名不太好,因為基於一些慣例,這表示 2.0 是一種取代 1.0 的方式。

但讀到這你應該知道這不正確,此外 2.0 相較之下比較複雜。甚至還有一些套件是用來簡化 2.0 的,例如 Flutter 官方文件中特別提到 go_router

命令式 Imperative vs 宣告式 Declarative

命令式和宣告式是撰寫程式碼的風格,各有適用的情境。命令式就像是下指令一樣,例如你使用 Navigator 1.0 直接 push 一個頁面。而宣告式的作法則是先定義好什麼狀態會對應什麼行為,例如 Flutter 的組件在構建 UI 時就是一種宣告式風格。

Navigator

Navigator 組件是讓使用者從頁面 A 切換到頁面 B 的關鍵。另外大部分情況,使用者在切換頁面時也需要將資料帶到新頁面,這是 Navigator 另一個重要任務。

從概念上來說,Flutter 中的導航其實是一系列畫面的堆疊:

  • 堆疊的最上層有一個元素。在 Navigator 中,堆疊最上層的元素就是目前應用程式顯示的畫面
  • 順序採用後進先出,也就是最後加入的畫面,會是第一個被移除
  • Navigatorpush()pop() 兩個方法來處理新增/移除堆疊中的畫面。這就是 1.0 的使用方式。
  • Navigator 組件有個 pages 屬性,類似堆疊裡面的畫面組件清單,而畫面的呈現,移除,都是基於組件的狀態來決定。這是屬於 2.0 的方式。

Navigator 1.0

1.0 的方式從 Flutter 建立以來就開始使用,絕大多數的程式都可以使用 Navigator 1.0 的方法切換畫面。因此了解這種方式很重要,在許多情況下將會是比較適合的方式而且也比較容易。

首先我們需要先了解 Route

Route

前面提到堆疊中的元素,其實就是 Route 。在 Flutter 中定義它們有多種方式。每當我們希望導航到一個新畫面,我們就需要定義一個新的 Route 組件還有一些屬性通過 RouteSettings 加入。

RouteSettings

這是一個單純的類別包含 Route 相關的資訊

MaterialPageRoute(
  builder: (context) => Screen(),
  settings: RouteSettings(
    name: '/new-screen',
    arguments: '這裡傳遞資料到新頁面',
  ),
),
  • name 路由的唯一識別值,下個章節會詳細說明
  • arguments 傳遞資訊到目標路由

MaterialPageRoute 和 CupertinoPageRoute

Route 是高階抽象的類別,應用不同的平台畫面預期的行為可能不一樣。因此在 Flutter 中,為了符合平台預期的行為支援了 2 種實作分別是 MaterialPageRouteCupertinoPageRoute 分別對應 Android 和 iOS。

因此,當你開發應用程式的時候必須決定要使用 Material Design 還是 iOS 或者根據情境兩者都支援。

彙整

有了上面基礎概念,接著我們可以來看看如何使用 Navigator 組件實作。回到 Hello World 專案,首先讓我們先在 main.dart 來建立第二個畫面的組件:

class DestinationDetails extends StatelessWidget {
  DestinationDetails({ required this.title });
  
  final String title;
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
    	appBar: AppBar(title: Text(title)),
      body: Center(
      	child: ElevatedButton(
        	child: Text('返回'),
          onPressed: () {
            // TODO
          }
        ),
      ),
    );
  }
}

這是一個簡單的 Stateless 組件,可以傳入 title 參數。然後定義了 build 方法。

Scaffold 組件屬於結構上層的組件可以指定 AppBar 顯示在螢幕上方,body 則為主要內容區塊,還支援其他如浮動按鈕等。作為 Material Design 組件意味著可以搭配其他 Material 組件。

上面已經使用了基本的 AppBar

appBar: AppBar(title: Text(title)),

然後 Center 組件單純負責置中在其內部的組件。最後,我們加入 ElevatedButton 組件,這裡就是後面我們要加入導航功能的地方。

現在,讓我們加入路由支援導航到新頁面。在一個基本專案的 _MyHomePageState 中找到 Column 組件,我們修改它的 children

children: [
  // ...
  ElevatedButton(
  	child: Text('跳轉'),
    onPressed: () {
      Navigator.of(context).push(
      	MaterialPageRoute(builder: (context) {
          return DestinationDetails(title: "標題");
        }),
      );
    },
  ),
]

跟之前的 ElevatedButton 類似,也是設定了 childonPressed 。但這次我們在 onPressed 使用了:

Navigator.of(context).push()

你可能注意到這個機制和之前學習的 InhertitedWidget 類似。,Navigator 本身利用了 InheritedWidget 的特性,搭配 GlobalKey ,表示我們可以使用 Navigator.of(context) 找到最近的 Navigator 物件實例。

實際上,在 MaterialApp 組件內部隱式建立了 Navigator 組件,因此我們可以通過 MaterialApp 取得。所以這個過程就是向上查找樹狀結構找到與 MaterialApp 組件關聯的 Navigator

然後我們 push() ,如我們之前學習的,Navigator 組件是一個堆疊,我們把新畫面加入堆疊,然後它就是顯示。而 push() 的參數是 MaterialPageRoute

MaterialPageRoute(builder: (context) { ... })

MaterialPageRoute 繼承了 Route 類別。它將 Material Design 的效果加入到 Route 中。路由負責儲存新畫面相關的資訊,然後關於新畫面的呈現由 builder 參數處理。

最後我們在 builder 中返回新的組件:

return DestinationDetails(title: '標題');

意思是路由會使用 builder 來構建畫面,而我們希望顯示的是 DestinationDetails 組件。

有了這些設定,我們現在可以嘗試跳轉到新畫面了。但是我們還需要加入返回按鈕的程式碼:

onPressed() {
  Navigator.of(context).pop();
}

這次我們使用 pop() 方法來從堆疊中移除當前頁面,從而返回到上一個頁面。

到此,我們完成了第一個導航範例。現在我們了解了如何建構多個頁面的應用程式,雖然初學看起來很複雜,但它們對應開發應用是非常有幫助的。

關於 Navigator

你可能看過範例使用的 Navigator 有點不太一樣。它們使用了 Navigator.pop(context) 取代 Navigator.of(context).pop() 。這兩種方法實際上是等價的,因為 Navigator.pop 方法的第一行程式碼就是使用 context 找到 Navigator 組件。兩種方式可以隨您的偏好使用。

具名路由

路由名稱是導航很重要的一部分。用於識別路由和其 Navigator 組件。我們可以定義一系列路並為每個路由關聯一個名稱,為路由和對應的畫面提供一個意義抽象。後續還可以使用路徑的結構來切換頁面,簡單說你可以把它們視為類似 URL 的使用方式。

使用具名路由切換

前面的例子很簡單,我們概略的理解了路由的使用。除此之外,在實務上通常我們會利用具名路由來組織路由結構,它讓我們可以達成:

  • 用一種更佳清楚的方式組織畫面
  • 集中畫面的建立
  • 傳遞參數到畫面

具名路由在 MaterialApp 組件中指定,接著讓我們來學習如何使用具名路由。

首先,在 MaterialApp 組件中定義 routes 參數

routes: {
  '/': (context) => MyHomePage(title: '首頁'),
  '/destination': (context) => DestinationDetails(title: '詳細頁面'),
}

在上面程式中,我們設定了兩個路由,/ 路徑對應 MyHomePage/destination 對應 DestinationDetails 組件。如果你現在嘗試執行程式碼,那麼你會遭遇錯誤。這是因為同時使用了 /home 參數。/ 是一個特殊的路由等同於 home 屬性,這表示我們定義了首頁 2 次,但 Flutter 不知道該使用那個。

移除 home 參數設定,即可執行應用程式。而稍早我們建立的命令式路由依舊可以執行,因為我們依舊將路由加入了堆疊之中。現在我們來調整剛剛 _MyHomePageState 的範例:

onPressed: () {
  Navigator.of(context).pushNamed('/destination');
}

相較於直接使用 Route 這種方式更加清楚。

參數

你可能注意到 DestinationDetailstitle 參數是固定的,而真實情況不太可能這樣;我們希望頁面能夠設定自己的參數。

為了解決這個問題,pushNamed 方法支援傳入參數到路由:

Navigator.of(context).pushNamed('/destination', arguments: "詳細頁面");

然而,傳遞參數會導致路由的設定增加複雜度,我們無法在使用 MaterialApproutes 參數,取而代之,我們必須使用 onGenerateRoute 參數來傳遞設定到 DestinationDetails 組件。onGenerateRoute 可以完全控制目標畫面。

「在 MaterialApp 移除 routes ,新增 onGenerateRoute

onGenerateRoute: (settings) {
  if (settings.name == '/') {
    return MaterialPageRoute(
    	builder: (context) => MyHomePage(title: "首頁")
    );
  } else if (settings.name == '/destination') {
    return MaterialPageRoute(
    	builder: (context) => DestinationDetails(title: settings.arguments as String)
    );
  }
}

onGenerateRoute 基於 settings.name 來決定回傳的路由,同時也可以搭配其他參數。看起來比較類似原本的 push 用法,但有個明顯的缺點就是 settings.arguments 少了型別安全,並且必須轉換型別如上面 as String

是否應該使用具名路由?

這裡沒有所謂正確答案,使用何種方式取決於個人偏好和專案的需求。 push() 方法可以支援型別安全的參數。而具名路由可以集中管理。

到此我們已經了解如何使用 pushpushNamed 切換畫面,以及它們如何傳遞參數。但還有一種情況,就是我們希望在 pop() 的時候將結果回傳到之前的頁面。舉例來說,我們的首頁有一個「選擇送貨地址」的按鈕,點擊之後進到下一個頁面選擇相關地址,當選擇完畢,使用者點擊了「確認」回到首頁。這種情況,首頁必須得知道使用者選擇了哪個地址。

這個回到首頁,就是 pop() 時希望將結果回傳。

從路由檢索資訊

當路由被推送,我們可能會希望從中取回某些資訊,例如在新頁面中我們讓使用者輸入的資料,我們可以通過 pop()result 參數取得回傳資料。

push() 方法和其它類似的方法會回傳 Future 。這個 Future 在路由 pop() 的時候會 resolve ,而 Future 的值就是 pop()result 參數。

我們已經看過了在 push() 的時候加入參數傳到新的路由。反過來當 pop() 的時候也可以。讓我們更新詳細頁面的組件的 build

Widget build(BuildContext context) {
  return Scaffold(
  	appBar: AppBar(title: Text(title)),
    body: Center(
    	child: Column(
      	mainAxisAlignment: MainAxisAlignment.center,
        children: [
          ElevatedButton(
          	child: Text('加入最愛'),
            onPressed: () {
              Navigator.of(context).pop(true);
            }
          ),
          ElevatedButton(
          	child: Text('關閉'),
            onPressed: () {
              Navigator.of(context).pop(false);
            }
          ),
        ],
      ),
    ),
  );
}

注意到除了一些組件結構的調整加入 ElevatedButton ,現在 pop() 也帶入參數。在這個範例,當使用者將項目加入最愛的時候回傳布林值,不過 pop() 可以使用任何型別。

然後我們接著更新 MyHomePageElevatedButton 使其可以接收回傳的值。

ElevatedButton(
	child: Text('詳細頁面'),
  onPressed: () async {
    // 重點
    bool? outcome = await Navigator.of(context).push(
    	MaterialPageRoute(
      	builder: (context) {
          return DestinationDetails(title: '詳細頁面');
        }
      ),
    );
    ScaffoldMessenger.of(context).showSnackBar(
    	SnackBar(content: Text("$outcome"))
    );
  }
)

push 回傳的結果是一個 Future,因此我們需要使用 await 等待結果處理完成。這也表示我們需要更新方法為匿名函式的格式,所以 onPressed 的函式加上了 async

最後我們使用很常見的 SnackBar顯示回傳的資訊。ScaffoldMessenger 前面提到是一個 InheritedWidget 因此也可以使用 .of 方法搜尋到物件,後續 showSnackBar 方法單純傳入 SnackBar 作為參數。

如你所見, Navigator 1.0 功能齊全且直覺易用,可以輕易的為你的應用加入導航功能。基本上可以應付大多數的需求,但如果你希望你的應用程式也支援網頁的話,那麼 Navigator 1.0 可能無法完全滿足需求。

Navigator 2.0

如同之前提到的, Navigator 1.0 在處理深層連結時有一些限制,舉例來說連結希望直接進入詳細頁面,但又希望堆疊可以包含列表讓使用者點擊返回的時候是回到列表頁。因此 Navigator 2.0 產生了。它採用了不同的宣告式方式來處理路由。

pages

Navigator 有一個叫 pages 的參數,可以接受一系列 Page 組件,當狀態發生改變,這個列表也會發生變化,進一步路由堆疊會更新以符合 pages

這和其他由子組件組成的列表組件的運作方式非常類似。例如 Column 組件,狀態改變導致其子組件發生變化時,Flutter 將會重新渲染畫面以反映這些變化。此方式最大的優點就是如果我們先設計好狀態,那麼與之相關的多個畫面堆疊可以自動基於狀態自行執行對應行為。如果是之前 1.0 的機制,我們可能會需要自己處理狀態的傳遞,然後自行根據狀態決定路由堆疊。相較之下 2.0 通過宣告的方式讓 Navigator 自動處理路由。

實作 Navigator 2.0

一般來說 2.0 被認為是相對複雜的導航方式,這個章節不會過於深入探討,但了解其如何運作對於後續開發還是很有幫助。一旦你更能掌握 Flutter,你可以更全面的深入學習這種方式。在這個範例,我們依舊使用 Hello World 範例,但是採用 2.0 的方式。

讓我們直接來看看完整的範例:

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  String? _selectedDestination;

  void _setDestination(String destination) {
    setState(() {
      _selectedDestination = destination;
    });
  }

  final GlobalKey<ScaffoldMessengerState> snackBarKey =
      GlobalKey<ScaffoldMessengerState>();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      scaffoldMessengerKey: snackBarKey,
      title: '路由範例',
      home: Navigator(
          pages: [
            MaterialPage(
              child: MyHomePage(
                title: '首頁',
                destinationCallback: _setDestination,
              ),
            ),
            if (_selectedDestination != null)
              MaterialPage(
                child: DestinationDetails(destination: _selectedDestination!),
              ),
          ],
          onPopPage: (route, result) {
            if (!route.didPop(result)) {
              return false;
            }

            if (result && _selectedDestination != null) {
              snackBarKey.currentState?.showSnackBar(
                SnackBar(
                  content: Text("加入 $_selectedDestination 到最愛清單"),
                  action: SnackBarAction(
                    label: '關閉',
                    onPressed: () {
                      snackBarKey.currentState?.hideCurrentSnackBar();
                    },
                  ),
                ),
              );
            }

            setState(() {
              _selectedDestination = null;
            });
            return true;
          }),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage(
      {super.key, required this.title, required this.destinationCallback});

  final String title;
  final void Function(String) destinationCallback;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(title)),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () {
                destinationCallback('項目 1');
              },
              child: const Text('項目 1'),
            ),
            ElevatedButton(
              onPressed: () {
                destinationCallback('項目 2');
              },
              child: const Text('項目 2'),
            ),
          ],
        ),
      ),
    );
  }
}

class DestinationDetails extends StatelessWidget {
  const DestinationDetails({super.key, required this.destination});

  final String destination;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(destination)),
      body: Center(
        child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
          ElevatedButton(
            child: const Text('加入最愛'),
            onPressed: () {
              Navigator.of(context).pop(true);
            },
          ),
          ElevatedButton(
            child: const Text("返回"),
            onPressed: () {
              Navigator.of(context).pop(false);
            },
          ),
        ]),
      ),
    );
  }
}

首先注意到我們將 MyApp 修改為 StatefulWidget ,這是因為 2.0 使用狀態來決定路由。接著我們加入 _selectedDestination

然後我們跳到 DestinationDetails 組件一樣使用 1.0 的 pop() 方法。但在 Navigator 中 1.0 和 2.0 的 pop() 作用非常不同。

接下來因為狀態在 MyApp 中而不是 MyHomePage,當目標選擇的時候,在 MyHomePage 的按鈕需要更新 MyApp 的狀態,也就是將 _selectedDestination 換成選擇的項目。

為了達成這個目的,我們在 MyApp 加入了 callback 方法,並傳入 MyHomePage,該方法單純使用 destination 作為參數,並使用 MyHomePage 提供的值來變更狀態。

接著因為我們需要在 MyHomePage 中使用這個方法,因此將其作為參數傳入。

const MyHomePage(
      {super.key, required this.title, required this.destinationCallback});

final String title;
final void Function(String) destinationCallback;

後續我們就可以使用這個方法了。最後我們需要關注的是 Navigator 的。pagesonPopPage

首先是 pages 參數,其包含了兩個畫面的列表,一個是 MyHomePage 組件,一個是 DestinationDetails 。在這個 pages 參數中會決定畫面,也就是當 _selectedDestination 不為空的時候顯示 DestinationDetails ,因此當 _setDestination 觸發狀態變更,目標狀態不再為空並重新渲染的時候, DestinationDetails 就會呈現在畫面上。

接著,是 onPopPage 。在這個方法,我們告訴 Navigator 當有頁面 pop 的時候需要做什麼。在這個方法,我們首先使用 .didPop()檢查 pop() 是否完成。在這個範例 DestinationDetails 觸發了 pop() 通過將 _selectedDestination 設為 null ,頁面將回到 MyHomePage

除了內建的 Navigator 2.0 ,還有許多簡化 2.0 的套件,例如 go_routerAutoRoute 。它們不只簡化了 Navigator 2.0 還加入了許多好用的功能,例如型別檢查,路由防護限制存取畫面,例如只有在使用者登入的情況才可存取畫面。

簡化路由套件 go_router

為了在設計專案架構的時候有更多知識可以去評估,這裡我們簡單介紹如何使用 Go Router 套件在 Flutter 應用程式中實現基本的頁面導航。

安裝套件:

$ flutter pub add go_router

讓我們直接上範例:

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() => runApp(MyApp());

final GoRouter _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => HomePage(),
    ),
    GoRoute(
      path: '/about',
      builder: (context, state) => AboutPage(),
    ),
  ],
);

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: _router,
      title: 'Go Router 範例',
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('首頁')),
      body: Center(
        child: ElevatedButton(
          child: Text('前往關於頁面'),
          onPressed: () => context.go('/about'),
        ),
      ),
    );
  }
}

class AboutPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('關於')),
      body: Center(
        child: ElevatedButton(
          child: Text('返回首頁'),
          onPressed: () => context.go('/'),
        ),
      ),
    );
  }
}
  1. 首先,我們需要設定 Go Router。在 main.dart 文件中,我們定義了路由配置。這裡我們定義了兩個路由:一個是根路徑 /,對應 HomePage;另一個是 /about,對應 AboutPage

  2. 接下來,我們需要在 MaterialApp 中使用 Go Router

  3. 在 HomePage 和 AboutPage 中,我們使用 context.go() 方法來實現頁面跳轉

  4. 在 AboutPage 中,我們使用相同的方法返回首頁

通過這個簡單的範例,我們展示了如何使用 Go Router 在 Flutter 應用程式中實現基本的頁面導航。Go Router 提供了一種簡潔而強大的方式來管理應用程式的路由,使得頁面之間的跳轉變得更加容易和靈活。

除了 Go Router ,目前主流的導航解決方案還有 AutoRouteBeamer 這兩者內建就支援路由保護機制(Guards)。

進階閱讀


上一篇
Day 15 進階 UI 組件
下一篇
Day 17 轉場與資料傳遞
系列文
Flutter 開發實戰 - 30 天逃離新手村38
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言